Amplifier output dB linearity check¶

This script is used to check the amplifier linearity between on screen db level difference and actual output using a GRAS reference microphone. The check is done by playing back a series of chirp sweeps and measuring the level with the GRAS at 1.5 m.

Experimental notes

Data for the experiments have been collected on June 3th, 2025.

  • 192 KHz recordings from the GRAS reference mic (GRAS 40BF + 26AC preamplifier)
  • 1.5 meters distance (~ far field = 10𝛌; 𝛌max = fmin; 343/2 = 171 Hz --> 10𝛌 = 1715 Hz; at 1000Hz: 4.5𝛌 = 4.5*0.343 = 1.5435 m)

HW settings:

  • Harman kandon AWR 445 vol varied from -20 to -40 dB
  • fireface analog out 1/2 stereo vol = 0db
  • tweeter #1
  • Ref mic: gras +30 db fireface channel 9, +20db channel A power module

WARNING This code is partly derived from the code:

example_w-deconvolution_runthrough.py

at this link: https://github.com/activesensingcollectives/calibrate-mic/blob/master/example_w-deconvolution_runthrough.py and makes use of:

utilities.py

both developed by Thejasvi Beleyur.

@author: Alberto Doimo

In [1]:
from IPython.display import Image
from IPython.display import display

path = '/home/alberto/Documents/ActiveSensingCollectives_lab/Ro-BATs/measurements/z723/array_calibration/226_238/'
display(
    Image(filename=path + "PXL_20250509_10061094.jpg", width=300),
    Image(filename=path + "PXL_20250509_10062195.jpg", width=300),
    Image(filename=path + "rme802_matrix.png", width=700),
    Image(filename=path + "rme802_mixer.png", width=700)
)
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image

Audio analysis¶

  1. Out signal used during the recordings: 0.2-24Khz sweeps of variable length. Having a signal with different set of sweeps of different length, allows to have repeatibility of the same signal type (useful to avarage and reduce errors), but also having the sweep energy distributed over different time frames.

    IMPORTANT NOTE

    The signal was reproduced from windows 11 and using Windows Media Player Software which result in outputting only the first 4 out of 5 sweeps, correctly spaced in time. Possibly there is an unexpected resampling or a digital cut of the last portion of the file, but it shouldn't affect the results, since this report only uses the first sweep for the analysis.

In [2]:
import numpy as np 
import soundfile as sf
import matplotlib.pyplot as plt 
from utilities import *
import scipy.signal as sig

fs = 192000
durns = np.array([3, 4, 5, 8] )*1e-3

chirp = []
all_sweeps = []
for durn in durns:
    t = np.linspace(0, durn, int(fs*durn))
    #start_f, end_f = 1e3, 20e3
    start_f, end_f = 2e2, 24e3
    sweep = signal.chirp(t, start_f, t[-1], end_f)
    #sweep *= signal.windows.tukey(sweep.size, 0.95)
    sweep *= signal.windows.tukey(sweep.size, 0.2)
    sweep *= 0.8
    sweep_padded = np.pad(sweep, pad_width=[int(fs*0.1)]*2, constant_values=[0,0])
    all_sweeps.append(sweep_padded)
    chirp.append(sweep)
  1. Data recordings import
In [3]:
DIR = "./2025-06-03/"  # Directory containing the audio files

# Load the 5 sweeps of the GRAS 40BF microphone at different levels

gras_40_or, fs = sf.read('./2025-06-03/-40db_02_24k_5sweeps_channel9_192k.wav')
gras_35_or, fs = sf.read('./2025-06-03/-35db_02_24k_5sweeps_channel9_192k.wav')
gras_30_or, fs = sf.read('./2025-06-03/-30db_02_24k_5sweeps_channel9_192k.wav')
gras_25_or, fs = sf.read('./2025-06-03/-25db_02_24k_5sweeps_channel9_192k.wav')
gras_20_or, fs = sf.read('./2025-06-03/-20db_02_24k_5sweeps_channel9_192k.wav')

# Load the 1 Pa reference tone 
gras_1Pa_tone, fs = sf.read('./2025-06-03/ref_tone_gras_1Pa_ch9_30dB_chA_20dB.wav', start=int(fs*0.5),
                        stop=int(fs*1.5))
  1. Matched filtering, peak extraction, RMS calculation:
  • Every file is matched with the output chirp template.
  • The sweep position is extracted from each file.
  • The extracted peaks are showed over the matched audio.
In [4]:
# Define the matched filter function
def matched_filter(recording, chirp_template):
    filtered_output = np.roll(signal.correlate(recording, chirp_template, 'same', method='direct'), -len(chirp_template)//2)
    filtered_output *= signal.windows.tukey(filtered_output.size, 0.1)
    filtered_envelope = np.abs(signal.hilbert(filtered_output))
    return filtered_envelope

# Detect peaks in the matched filter output
def detect_peaks(filtered_output, sample_rate):
    peaks, properties = signal.find_peaks(filtered_output, prominence=5, distance=0.2 * sample_rate)
    return peaks

chirp_to_use = 0 # Use the first chirp for matching

gras_40_matched = matched_filter(gras_40_or, chirp[chirp_to_use])
gras_35_matched = matched_filter(gras_35_or, chirp[chirp_to_use])
gras_30_matched = matched_filter(gras_30_or, chirp[chirp_to_use])
gras_25_matched = matched_filter(gras_25_or, chirp[chirp_to_use])
gras_20_matched = matched_filter(gras_20_or, chirp[chirp_to_use])

# Detect peaks
peaks_gras_40 = detect_peaks(matched_filter(gras_40_matched, chirp[chirp_to_use]), fs)
peaks_gras_35 = detect_peaks(matched_filter(gras_35_matched, chirp[chirp_to_use]), fs)
peaks_gras_30 = detect_peaks(matched_filter(gras_30_matched, chirp[chirp_to_use]), fs)
peaks_gras_25 = detect_peaks(matched_filter(gras_25_matched, chirp[chirp_to_use]), fs)
peaks_gras_20 = detect_peaks(matched_filter(gras_20_matched, chirp[chirp_to_use]), fs)

print(f"Detected peaks: {len(peaks_gras_40)}")
print(f"Detected peaks: {len(peaks_gras_35)}")
print(f"Detected peaks: {len(peaks_gras_30)}")
print(f"Detected peaks: {len(peaks_gras_25)}")
print(f"Detected peaks: {len(peaks_gras_20)}")

# Plot the matched filter outputs and detected peaks for all audio files
plt.figure(figsize=(15, 10))

audio_labels = ['-40 dB', '-35 dB', '-30 dB', '-25 dB', '-20 dB']
matched_outputs = [gras_40_matched, gras_35_matched, gras_30_matched, gras_25_matched, gras_20_matched]
peaks_list = [peaks_gras_40, peaks_gras_35, peaks_gras_30, peaks_gras_25, peaks_gras_20]

for i, (matched, peaks, label) in enumerate(zip(matched_outputs, peaks_list, audio_labels), 1):
    plt.subplot(5, 1, i)
    t = np.linspace(0, len(matched) / fs, len(matched))
    plt.plot(t, matched, label=f'Matched Output {label}')
    plt.plot(peaks / fs, matched[peaks], 'ro', label='Detected Peaks')
    plt.title(f'Matched Filter Output - GRAS {label}')
    plt.xlabel('Time [s]')
    plt.ylabel('Amplitude')
    plt.legend()
    plt.tight_layout()

plt.show()
Detected peaks: 8
Detected peaks: 8
Detected peaks: 8
Detected peaks: 8
Detected peaks: 8
No description has been provided for this image
  1. Extracted audio chunks from the matched filter outputs are displayed
In [5]:
gras_40 = gras_40_or[int(peaks_gras_40[chirp_to_use]):int(peaks_gras_40[chirp_to_use]) + int(fs*durns[chirp_to_use])]
gras_35 = gras_35_or[int(peaks_gras_35[chirp_to_use]):int(peaks_gras_35[chirp_to_use]) + int(fs*durns[chirp_to_use])]
gras_30 = gras_30_or[int(peaks_gras_30[chirp_to_use]):int(peaks_gras_30[chirp_to_use]) + int(fs*durns[chirp_to_use])]
gras_25 = gras_25_or[int(peaks_gras_25[chirp_to_use]):int(peaks_gras_25[chirp_to_use]) + int(fs*durns[chirp_to_use])]
gras_20 = gras_20_or[int(peaks_gras_20[chirp_to_use]):int(peaks_gras_20[chirp_to_use]) + int(fs*durns[chirp_to_use])]

# Plot the extracted chirp segments for each amplifier level
plt.figure(figsize=(10, 15))
audio_segments = [gras_40, gras_35, gras_30, gras_25, gras_20]
labels = ['-40 dB', '-35 dB', '-30 dB', '-25 dB', '-20 dB']

# Find global min and max for y-axis
ymin = min([segment.min() for segment in audio_segments])
ymax = max([segment.max() for segment in audio_segments])

for i, (segment, label) in enumerate(zip(audio_segments, labels), 1):
    t = np.linspace(0, len(segment) / fs, len(segment))
    plt.subplot(5, 1, i)
    plt.plot(t, segment)
    plt.title(f'Extracted Chirp Segment: {label}')
    plt.xlabel('Time [s]')
    plt.ylabel('Amplitude')
    plt.ylim(ymin, ymax)
    plt.tight_layout()
    plt.grid()
plt.show()
No description has been provided for this image
  1. RMS is calculated for all the chunks. dB rms SPL value for each recording is calculated, using the RMS of audio chunks and rms of ref 1Pa tone, thanks to the overall flat sensitivity of the GRAS.
In [6]:
rms_1Pa_tone = rms(gras_1Pa_tone)
print(f'The calibration mic has a sensitivity of {np.round(rms_1Pa_tone,3)}rms/Pa. RMS relevant only for this ADC!')

# Convert from RMS to Pascals (rms equivalent) 
gras_overallaudio_Parms_40 = rms(gras_40)/rms_1Pa_tone
gras_overallaudio_Parms_35 = rms(gras_35)/rms_1Pa_tone
gras_overallaudio_Parms_30 = rms(gras_30)/rms_1Pa_tone
gras_overallaudio_Parms_25 = rms(gras_25)/rms_1Pa_tone
gras_overallaudio_Parms_20 = rms(gras_20)/rms_1Pa_tone

# Convert from Pascals to db SPL 
print(f'GRAS dBrms SPL measures:{pascal_to_dbspl(gras_overallaudio_Parms_40)} for -40 dB amplifier level')
print(f'GRAS dBrms SPL measures:{pascal_to_dbspl(gras_overallaudio_Parms_35)} for -35 dB amplifier level')
print(f'GRAS dBrms SPL measures:{pascal_to_dbspl(gras_overallaudio_Parms_30)} for -30 dB amplifier level')
print(f'GRAS dBrms SPL measures:{pascal_to_dbspl(gras_overallaudio_Parms_25)} for -25 dB amplifier level')
print(f'GRAS dBrms SPL measures:{pascal_to_dbspl(gras_overallaudio_Parms_20)} for -20 dB amplifier level')

# plot 
db_levels = [-40, -35, -30, -25, -20]
dbspl_values = [
    pascal_to_dbspl(gras_overallaudio_Parms_40),
    pascal_to_dbspl(gras_overallaudio_Parms_35),
    pascal_to_dbspl(gras_overallaudio_Parms_30),
    pascal_to_dbspl(gras_overallaudio_Parms_25),
    pascal_to_dbspl(gras_overallaudio_Parms_20)
]
min_val = min(dbspl_values)
normalized = [v - min_val for v in dbspl_values]

plt.figure(figsize=(10, 6))
plt.plot(db_levels, dbspl_values, marker='o')
for x, y, norm in zip(db_levels, dbspl_values, normalized):
    plt.annotate(f'{norm[0]:.2f} dB', (x-1, y+1), textcoords="offset points", xytext=(10,5), ha='left', fontsize=9)
plt.title('Amplifier linearity check')
plt.xlabel('Amplifier output Level [dB]')
plt.ylabel('dBrms SPL')
plt.xticks(db_levels)
plt.yticks(np.array(dbspl_values).reshape(1, 5)[0])
plt.grid(True)
plt.tight_layout()
plt.show()
The calibration mic has a sensitivity of 0.029rms/Pa. RMS relevant only for this ADC!
GRAS dBrms SPL measures:[84.48002855] for -40 dB amplifier level
GRAS dBrms SPL measures:[89.3826392] for -35 dB amplifier level
GRAS dBrms SPL measures:[94.31868564] for -30 dB amplifier level
GRAS dBrms SPL measures:[99.32138425] for -25 dB amplifier level
GRAS dBrms SPL measures:[104.1481838] for -20 dB amplifier level
No description has been provided for this image

Final Notes¶

  • The variability of the output difference with respect to the actual values declared on the Amplifier display is small. in this experiemnt with 20 db is off of only 0.33 dB:

    104.15 - 84.48 = 19.67;

    20 - 19.67 = 0.33 dB